iT邦幫忙

2024 iThome 鐵人賽

DAY 23
0

Current Sprint: 3. 遊戲基本流程完成
repo: https://github.com/side-project-at-SPT/ithome-ironman-2024-san-juan
swagger docs: https://side-project-at-spt.github.io/ithome-ironman-2024-san-juan/

前情提要

  • 在 game_data 新增 rounds,說明目前是第幾回合
  • 在 game 新增 steps,說明目前是第幾步
  • 新增 GameStep model,儲存每一步的快照狀態

今天要做什麼

預估還有一些時間(?,嘗試把前端的畫面完成一下,大家應該比較容易看出遊戲有什麼功能 XD

  • ⛳ 🆕 串接 WebSocket (use ActionCable)

sprint 3 遊戲基本流程完成

  • ⛳ 🆕 串接 WebSocket (use ActionCable)
  • 建築卡片資料
  • ✅ 增加遊戲狀態 phase,用來描述目前是哪個職業階段
  • 選職業:開始職業階段
  • 行動結束,更換目前玩家
  • 執行 礦工 階段行動
  • 建立 step model 用來儲存遊戲(每一步)紀錄
  • 🚧 回合開始
    • 檢查手牌
    • 銀行行動 (擴充)
    • 教堂行動 (擴充)
  • 建築卡片功能實作
  • 執行 議員 階段行動
  • 執行 建築 階段行動
  • 執行 生產 階段行動
  • 執行 交易 階段行動
  • 遊戲結束

串接 WebSocket

我們採用 ActionCable 來實現 WebSocket 的功能

修改 config/cable.yml

development:
  adapter: redis
  url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
  channel_prefix: ithome_ironman_2024_san_juan_development

test:
  adapter: test

production:
  adapter: redis
  url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
  channel_prefix: ithome_ironman_2024_san_juan_production

修改 產生玩家 方法

之前的做法是寫死 1 human + 3 bots
我們讓 Game.start_new_game 及其中的 generate_players 增加 players 參數,讓我們可以把 玩家 ID 放進去產生遊戲

# app/models/game.rb

class Game < ApplicationRecord
# ...
  class << self
  # ...
    def start_new_game(seed: nil, game: nil, players: nil)
    # ...
    end
    
    def generate_players(seed: nil, players: nil)
      srand(seed.to_i(16)) if seed

      if players
        human_players = players.map { |player| Player.new(player, [], [], nil, false) }
        bot_players = (4 - players.size).times.map { |i| Player.new("bot_#{i + 1}", [], [], nil, true) }
      else
        human_players = [ Player.new(1, [], [], nil, false) ]
        bot_players = 3.times.map { |i| Player.new(i + 2, [], [], nil, true) }
      end

      (human_players + bot_players).shuffle
    end
    # ...
  end
  # ...
end
# ...

設定連線資訊

當前端請求建立 WebSocket 連線時,會先由 connection.rb 辨認並註記 identified_by :current_user
這個 current_user 可以在底下所有的 channel 中取用,如此達到識別身份的目的

# app/channels/application_cable/connection.rb

module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :current_user

    def connect
      self.current_user = find_verified_user
    end

    private

    def find_verified_user
      if verified_user = MockUser.find_by(id: JWT.decode(request.params[:token], Rails.application.secret_key_base).first["sub"])
        verified_user
      else
        reject_unauthorized_connection
      end
    end
  end
end

目前還沒有建立使用者 model,先定義 MockUser 展示用

# app/models/mock_user.rb

class MockUser
  class << self
    def find_by(id:)
      return new("visitor") unless id

      new(id)
    end
  end

  def initialize(id)
    @id = id
  end

  def id
    @id
  end

  def email
    "mock_user_#{@id}@localhost"
  end
end

新增登入的 api

# app/controllers/api/v1/sessions_controller.rb

class Api::V1::SessionsController < ApplicationController
  def create
    id = Time.now.to_i
    token = JWT.encode({ sub: id }, Rails.application.secret_key_base)

    render json: { status: :created, token: token }
  end
end
# config/routes.rb

Rails.application.routes.draw do
# ...
  
  namespace :api do
    namespace :v1 do
      post "login" => "sessions#create", as: :login
      
      # ...
      
      end
    end
  end
end

這裡因為採用前後端不同 host,WebSocket 沒辦法以 cookie 方式驗證,於是簽發 JWT 作為驗證身份手段

建立 lobby channel

再來是 channel

當成功建立連線後,使用者可以訂閱頻道,當有訊息發到頻道時,便能收到通知
也可以呼叫頻道定義的方法,去跟後端互動

我們建立一個 Lobby 的頻道,發送供所有人觀看訊息的地方
可以作為發送訊息

  • 系統訊息
  • 玩家 上線 / 離線
  • 房間 開啟 / 關閉 / 開始遊戲

或是一般性的互動

  • 聊天 chat
  • 查詢房間列表 room_list
  • 查詢房間資訊 room_info
rais g channel lobby

第一次寫把 房間的邏輯 也混進去了
有關房間的

  • 開啟
  • 加入
  • 狀態變更
    • 有人加入
    • 有人離開
    • 房間關閉
    • 開始遊戲

room_channel 操作會比較適合
而遊戲的訊息推送,可以讓玩家訂閱 game_channel(id, player) 來發送手牌的個人訊息
而從 game_channel(id) 發送公開訊息 (或是旁觀者)

房間的房名、房主、成員等資訊,就紀錄在 redis

# app/channels/lobby_channel.rb

class LobbyChannel < ApplicationCable::Channel
  def subscribed
    stream_from "lobby_channel"
    data = { message: "Hello, #{current_user.email}!" }
    ActionCable.server.broadcast "lobby_channel", data
  end

  def unsubscribed
    # Any cleanup needed when channel is unsubscribed
  end

  def speak(data)
    ActionCable.server.broadcast "lobby_channel", message: data
  end

  def create_room
    room = Kredis.string "room:#{SecureRandom.hex(4)}"
    room.value = "Room #{room.key}"
    room_owner = Kredis.string "#{room.key}:owner"
    room_owner.value = current_user.email
    room_participants = Kredis.set "#{room.key}:participants"
    room_participants.add current_user.email
    message = { message: "#{room.value} created!" }
    ActionCable.server.broadcast "lobby_channel", message
  end

  def get_rooms
    rooms = $redis.scan_each(match: "room:*").map do |room|
      room_name = room.split(":")[1]
      room_name
    end
    message = { rooms: rooms.uniq }
    ActionCable.server.broadcast "lobby_channel", message
  end

  def get_participant_rooms
    # Get all rooms where the current user is a participant
    rooms = $redis.scan_each(match: "room:*:participants").map do |room|
      if $redis.smembers(room).include? current_user.email
        room_name = room.split(":")[1]
        room_name
      end
    end
    rooms.compact!
    message = { rooms: rooms.uniq }
    ActionCable.server.broadcast "lobby_channel", message
  end

  def leave_room(params)
    action = params["action"]
    room_key = params["room"]
    # can not leave room if you are not in the room
    room_participants = Kredis.set "room:#{room_key}:participants"
    if room_participants.include? current_user.email
      # close room if you are the owner
      room_owner = Kredis.string "room:#{room_key}:owner"
      if room_owner.value == current_user.email
        room = Kredis.string "room:#{room_key}"
        room_owner = Kredis.string "room:#{room_key}:owner"

        room_participants.clear
        room_owner.clear
        room.clear
        # Kredis.del "room:#{room_key}"
        # Kredis.del "room:#{room_key}:owner"
        # Kredis.del "room:#{room_key}:participants"
        message = { message: "Room #{room_key} closed!" }
        ActionCable.server.broadcast "lobby_channel", message
      else
        room_participants.remove current_user.email
        message = { message: "You have left room #{room_key}" }
        ActionCable.server.broadcast "lobby_channel", message
      end
    else
      message = { message: "You are not in room #{room_key}" }
      ActionCable.server.broadcast "lobby_channel", message
    end
  end

  def clear_rooms
    count = 0
    $redis.scan_each(match: "room:*").each do |room|
      $redis.del room
      count += 1
    end
    message = { message: "#{count} rooms cleared!" }
    ActionCable.server.broadcast "lobby_channel", message
  end

  def join_room(params)
    room_key = params["room"]
    room_participants = Kredis.set "room:#{room_key}:participants"
    room_participants.add current_user.email
    message = { message: "You have joined room #{room_key}" }
    ActionCable.server.broadcast "lobby_channel", message
  end

  def show_room_info(params)
    room_key = params["room"]
    room = Kredis.string "room:#{room_key}"
    room_owner = Kredis.string "room:#{room_key}:owner"
    room_participants = Kredis.set "room:#{room_key}:participants"
    message = {
      message: "Room #{room.value} info: owner - #{room_owner.value}, participants - #{room_participants.members.to_sentence}",
      owner: room_owner.value,
      participants: room_participants.members
    }
    ActionCable.server.broadcast "lobby_channel", message
  end

  def start_new_game(params)
    room_key = params["room"]
    room_owner = Kredis.string "room:#{room_key}:owner"

    case room_owner.value
    when nil
      # room does not exist
      message = { message: "Room #{room_key} does not exist" }
      ActionCable.server.broadcast "lobby_channel", message
    when current_user.email
      # room owner
      room_participants = Kredis.set "room:#{room_key}:participants"
      game = Game.start_new_game(players: room_participants.members)
      message = { message: "Game started in room #{room_key}", game_id: game.id }
      ActionCable.server.broadcast "lobby_channel", message
    else
      message = { message: "Only the room owner can start the game" }
      ActionCable.server.broadcast "lobby_channel", message
    end
  end
end

部署

如果是透過 zeabur (https://zeabur.com/templates/KQZHXT) 部署的話,可從服務市集選取 redis 即可
並注意

  1. config/environments/production.rb config.action_cable.allowed_request_origins
  2. zeabur 環境變數 新增 REDIS_URL => ${REDIS_URI}

前端 subscribe / binding

請參考 https://github.com/side-project-at-SPT/ithome-ironman-2024-san-juan-frontend-example

過兩天應該會用 vue 改寫 /images/emoticon/emoticon34.gif

收工.

明天要做什麼

  • 串接 websocket
    • 🆕 room_channel
    • 🆕 game_channel

以上不代表明天會做,如有雷同純屬巧合


工商服務

SPT (Side Project Taiwan) 的宗旨是藉由Side Project開發來成就自我,透過持續學習和合作,共同推動技術和專業的發展。我們相信每一個參與者,無論是什麼專業,都能在這個社群中找到屬於自己的成長空間。

歡迎所有對Side Project開發有興趣的人加入我們,可以是有點子來找夥伴,也可以是來尋找有興趣的Side Project加入,邀請大家一同打造一個充滿活力且有意義的技術社群!

Discord頻道連結: https://sideproj.tw/dc


上一篇
Day 22 - 建立 Step model 用來儲存遊戲(每一步)紀錄
下一篇
(TBD) Day 24 - 前端 UI
系列文
透過實作網頁遊戲練習網站工程師的基本素養,以 San Juan(聖胡安) 為例。30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言